Aggregation¶
Speed up large problems with time series aggregation techniques.
This notebook introduces:
- Resampling: Reduce time resolution (e.g., hourly → 4-hourly)
- Clustering: Identify typical periods (e.g., 8 representative days)
- Two-stage optimization: Size with reduced data, dispatch at full resolution
- Speed vs. accuracy trade-offs: When to use each technique
Setup¶
In [1]:
Copied!
import timeit
import pandas as pd
import plotly.express as px
import xarray as xr
import flixopt as fx
fx.CONFIG.notebook()
import timeit import pandas as pd import plotly.express as px import xarray as xr import flixopt as fx fx.CONFIG.notebook()
Out[1]:
flixopt.config.CONFIG
Load Time Series Data¶
We use real-world district heating data at 15-minute resolution (one month):
In [2]:
Copied!
# Load time series data (15-min resolution)
data = pd.read_csv('data/Zeitreihen2020.csv', index_col=0, parse_dates=True).sort_index()
data = data['2020-01-01':'2020-01-31 23:45:00'] # One month
data.index.name = 'time' # Rename index for consistency
timesteps = data.index
# Extract profiles
electricity_demand = data['P_Netz/MW'].to_numpy()
heat_demand = data['Q_Netz/MW'].to_numpy()
electricity_price = data['Strompr.€/MWh'].to_numpy()
gas_price = data['Gaspr.€/MWh'].to_numpy()
print(f'Timesteps: {len(timesteps)} ({len(timesteps) / 96:.0f} days at 15-min resolution)')
print(f'Heat demand: {heat_demand.min():.1f} - {heat_demand.max():.1f} MW')
print(f'Electricity price: {electricity_price.min():.1f} - {electricity_price.max():.1f} €/MWh')
# Load time series data (15-min resolution) data = pd.read_csv('data/Zeitreihen2020.csv', index_col=0, parse_dates=True).sort_index() data = data['2020-01-01':'2020-01-31 23:45:00'] # One month data.index.name = 'time' # Rename index for consistency timesteps = data.index # Extract profiles electricity_demand = data['P_Netz/MW'].to_numpy() heat_demand = data['Q_Netz/MW'].to_numpy() electricity_price = data['Strompr.€/MWh'].to_numpy() gas_price = data['Gaspr.€/MWh'].to_numpy() print(f'Timesteps: {len(timesteps)} ({len(timesteps) / 96:.0f} days at 15-min resolution)') print(f'Heat demand: {heat_demand.min():.1f} - {heat_demand.max():.1f} MW') print(f'Electricity price: {electricity_price.min():.1f} - {electricity_price.max():.1f} €/MWh')
Timesteps: 2976 (31 days at 15-min resolution) Heat demand: 122.2 - 266.2 MW Electricity price: -3.3 - 72.6 €/MWh
In [3]:
Copied!
# Visualize first week
profiles = xr.Dataset(
{
'Heat Demand [MW]': xr.DataArray(heat_demand[:672], dims=['time'], coords={'time': timesteps[:672]}),
'Electricity Price [€/MWh]': xr.DataArray(
electricity_price[:672], dims=['time'], coords={'time': timesteps[:672]}
),
}
)
df = profiles.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value')
fig = px.line(df, x='time', y='value', facet_col='variable', height=300)
fig.update_yaxes(matches=None, showticklabels=True)
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig
# Visualize first week profiles = xr.Dataset( { 'Heat Demand [MW]': xr.DataArray(heat_demand[:672], dims=['time'], coords={'time': timesteps[:672]}), 'Electricity Price [€/MWh]': xr.DataArray( electricity_price[:672], dims=['time'], coords={'time': timesteps[:672]} ), } ) df = profiles.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value') fig = px.line(df, x='time', y='value', facet_col='variable', height=300) fig.update_yaxes(matches=None, showticklabels=True) fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1])) fig
Build the Base FlowSystem¶
A typical district heating system with investment decisions:
In [4]:
Copied!
def build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price):
"""Build a district heating system with CHP, boiler, and storage (with investment options)."""
fs = fx.FlowSystem(timesteps)
fs.add_elements(
# Buses
fx.Bus('Electricity'),
fx.Bus('Heat'),
fx.Bus('Gas'),
fx.Bus('Coal'),
# Effects
fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True),
fx.Effect('CO2', 'kg', 'CO2 Emissions'),
# CHP with investment optimization
fx.linear_converters.CHP(
'CHP',
thermal_efficiency=0.58,
electrical_efficiency=0.22,
electrical_flow=fx.Flow('P_el', bus='Electricity', size=200),
thermal_flow=fx.Flow(
'Q_th',
bus='Heat',
size=fx.InvestParameters(
minimum_size=100,
maximum_size=300,
effects_of_investment_per_size={'costs': 10},
),
relative_minimum=0.3,
),
fuel_flow=fx.Flow('Q_fu', bus='Coal'),
),
# Gas Boiler with investment optimization
fx.linear_converters.Boiler(
'Boiler',
thermal_efficiency=0.85,
thermal_flow=fx.Flow(
'Q_th',
bus='Heat',
size=fx.InvestParameters(
minimum_size=0,
maximum_size=150,
effects_of_investment_per_size={'costs': 5},
),
relative_minimum=0.1,
),
fuel_flow=fx.Flow('Q_fu', bus='Gas'),
),
# Thermal Storage with investment optimization
fx.Storage(
'Storage',
capacity_in_flow_hours=fx.InvestParameters(
minimum_size=0,
maximum_size=1000,
effects_of_investment_per_size={'costs': 0.5},
),
initial_charge_state=0,
eta_charge=1,
eta_discharge=1,
relative_loss_per_hour=0.001,
charging=fx.Flow('Charge', size=137, bus='Heat'),
discharging=fx.Flow('Discharge', size=158, bus='Heat'),
),
# Fuel sources
fx.Source(
'GasGrid',
outputs=[fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})],
),
fx.Source(
'CoalSupply',
outputs=[fx.Flow('Q_Coal', bus='Coal', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})],
),
# Electricity grid connection
fx.Source(
'GridBuy',
outputs=[
fx.Flow(
'P_el',
bus='Electricity',
size=1000,
effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3},
)
],
),
fx.Sink(
'GridSell',
inputs=[fx.Flow('P_el', bus='Electricity', size=1000, effects_per_flow_hour=-(electricity_price - 0.5))],
),
# Demands
fx.Sink('HeatDemand', inputs=[fx.Flow('Q_th', bus='Heat', size=1, fixed_relative_profile=heat_demand)]),
fx.Sink(
'ElecDemand', inputs=[fx.Flow('P_el', bus='Electricity', size=1, fixed_relative_profile=electricity_demand)]
),
)
return fs
flow_system = build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price)
print(f'System: {len(timesteps)} timesteps')
def build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price): """Build a district heating system with CHP, boiler, and storage (with investment options).""" fs = fx.FlowSystem(timesteps) fs.add_elements( # Buses fx.Bus('Electricity'), fx.Bus('Heat'), fx.Bus('Gas'), fx.Bus('Coal'), # Effects fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True), fx.Effect('CO2', 'kg', 'CO2 Emissions'), # CHP with investment optimization fx.linear_converters.CHP( 'CHP', thermal_efficiency=0.58, electrical_efficiency=0.22, electrical_flow=fx.Flow('P_el', bus='Electricity', size=200), thermal_flow=fx.Flow( 'Q_th', bus='Heat', size=fx.InvestParameters( minimum_size=100, maximum_size=300, effects_of_investment_per_size={'costs': 10}, ), relative_minimum=0.3, ), fuel_flow=fx.Flow('Q_fu', bus='Coal'), ), # Gas Boiler with investment optimization fx.linear_converters.Boiler( 'Boiler', thermal_efficiency=0.85, thermal_flow=fx.Flow( 'Q_th', bus='Heat', size=fx.InvestParameters( minimum_size=0, maximum_size=150, effects_of_investment_per_size={'costs': 5}, ), relative_minimum=0.1, ), fuel_flow=fx.Flow('Q_fu', bus='Gas'), ), # Thermal Storage with investment optimization fx.Storage( 'Storage', capacity_in_flow_hours=fx.InvestParameters( minimum_size=0, maximum_size=1000, effects_of_investment_per_size={'costs': 0.5}, ), initial_charge_state=0, eta_charge=1, eta_discharge=1, relative_loss_per_hour=0.001, charging=fx.Flow('Charge', size=137, bus='Heat'), discharging=fx.Flow('Discharge', size=158, bus='Heat'), ), # Fuel sources fx.Source( 'GasGrid', outputs=[fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})], ), fx.Source( 'CoalSupply', outputs=[fx.Flow('Q_Coal', bus='Coal', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})], ), # Electricity grid connection fx.Source( 'GridBuy', outputs=[ fx.Flow( 'P_el', bus='Electricity', size=1000, effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3}, ) ], ), fx.Sink( 'GridSell', inputs=[fx.Flow('P_el', bus='Electricity', size=1000, effects_per_flow_hour=-(electricity_price - 0.5))], ), # Demands fx.Sink('HeatDemand', inputs=[fx.Flow('Q_th', bus='Heat', size=1, fixed_relative_profile=heat_demand)]), fx.Sink( 'ElecDemand', inputs=[fx.Flow('P_el', bus='Electricity', size=1, fixed_relative_profile=electricity_demand)] ), ) return fs flow_system = build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price) print(f'System: {len(timesteps)} timesteps')
System: 2976 timesteps
Technique 1: Resampling¶
Reduce time resolution to speed up optimization:
In [5]:
Copied!
solver = fx.solvers.HighsSolver(mip_gap=0.01)
# Resample from 1h to 4h resolution
fs_resampled = flow_system.transform.resample('4h')
print(f'Original: {len(flow_system.timesteps)} timesteps')
print(f'Resampled: {len(fs_resampled.timesteps)} timesteps')
print(f'Reduction: {(1 - len(fs_resampled.timesteps) / len(flow_system.timesteps)) * 100:.0f}%')
solver = fx.solvers.HighsSolver(mip_gap=0.01) # Resample from 1h to 4h resolution fs_resampled = flow_system.transform.resample('4h') print(f'Original: {len(flow_system.timesteps)} timesteps') print(f'Resampled: {len(fs_resampled.timesteps)} timesteps') print(f'Reduction: {(1 - len(fs_resampled.timesteps) / len(flow_system.timesteps)) * 100:.0f}%')
Original: 2976 timesteps Resampled: 186 timesteps Reduction: 94%
In [6]:
Copied!
# Optimize resampled system
start = timeit.default_timer()
fs_resampled.optimize(solver)
time_resampled = timeit.default_timer() - start
print(f'\nResampled optimization: {time_resampled:.2f} seconds')
print(f'Cost: {fs_resampled.solution["costs"].item():.2f} €')
# Optimize resampled system start = timeit.default_timer() fs_resampled.optimize(solver) time_resampled = timeit.default_timer() - start print(f'\nResampled optimization: {time_resampled:.2f} seconds') print(f'Cost: {fs_resampled.solution["costs"].item():.2f} €')
2025-12-17 07:52:18.249 WARNING │ ┌─ Flow CHP(Q_th) has a relative_minimum of <xarray.DataArray 'CHP(Q_th)|relative_minimum' (time: 186)> Size: 1kB │ │ array([0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3]) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 1kB 2020-01-01 ... 2020-01-31T20:00:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
2025-12-17 07:52:18.393 WARNING │ ┌─ Flow Boiler(Q_th) has a relative_minimum of <xarray.DataArray 'Boiler(Q_th)|relative_minimum' (time: 186)> Size: 1kB │ │ array([0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1]) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 1kB 2020-01-01 ... 2020-01-31T20:00:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms
MIP linopy-problem-yw6m4qia has 5616 rows; 5056 cols; 16609 nonzeros; 375 integer variables (375 binary)
Coefficient ranges:
Matrix [1e-05, 1e+03]
Cost [1e+00, 1e+00]
Bound [1e+00, 1e+03]
RHS [1e+00, 1e+00]
Presolving model
2424 rows, 1680 cols, 5777 nonzeros 0s
1876 rows, 1067 cols, 5690 nonzeros 0s
1866 rows, 1057 cols, 5700 nonzeros 0s
Presolve reductions: rows 1866(-3750); columns 1057(-3999); nonzeros 5700(-10909)
Solving MIP model with:
1866 rows
1057 cols (375 binary, 0 integer, 0 implied int., 682 continuous, 0 domain fixed)
5700 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% -59124376.75961 inf inf 0 0 0 0 0.0s
R 0 0 0 0.00% 2218254.926085 2289028.646004 3.09% 0 0 0 911 0.1s
C 0 0 0 0.00% 2218254.926085 2271602.112967 2.35% 1562 192 0 1113 0.1s
L 0 0 0 0.00% 2218254.926085 2218254.926085 0.00% 1562 192 0 1113 0.2s
1 0 1 100.00% 2218254.926085 2218254.926085 0.00% 1562 192 0 1200 0.2s
Solving report
Model linopy-problem-yw6m4qia
Status Optimal
Primal bound 2218254.92609
Dual bound 2218254.92609
Gap 0% (tolerance: 1%)
P-D integral 0.00418773753412
Solution status feasible
2218254.92609 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.22
Max sub-MIP depth 1
Nodes 1
Repair LPs 0
LP iterations 1200
0 (strong br.)
202 (separation)
87 (heuristics)
Resampled optimization: 2.08 seconds
Cost: 2218254.93 €
Technique 2: Two-Stage Optimization¶
- Stage 1: Size components with resampled data (fast)
- Stage 2: Fix sizes and optimize dispatch at full resolution
In [7]:
Copied!
# Stage 1: Sizing with resampled data
start = timeit.default_timer()
fs_sizing = flow_system.transform.resample('4h')
fs_sizing.optimize(solver)
time_stage1 = timeit.default_timer() - start
print('=== Stage 1: Sizing ===')
print(f'Time: {time_stage1:.2f} seconds')
print('\nOptimized sizes:')
for name, size in fs_sizing.statistics.sizes.items():
print(f' {name}: {float(size.item()):.1f}')
# Stage 1: Sizing with resampled data start = timeit.default_timer() fs_sizing = flow_system.transform.resample('4h') fs_sizing.optimize(solver) time_stage1 = timeit.default_timer() - start print('=== Stage 1: Sizing ===') print(f'Time: {time_stage1:.2f} seconds') print('\nOptimized sizes:') for name, size in fs_sizing.statistics.sizes.items(): print(f' {name}: {float(size.item()):.1f}')
2025-12-17 07:52:20.433 WARNING │ ┌─ Flow CHP(Q_th) has a relative_minimum of <xarray.DataArray 'CHP(Q_th)|relative_minimum' (time: 186)> Size: 1kB │ │ array([0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, │ │ 0.3, 0.3, 0.3, 0.3]) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 1kB 2020-01-01 ... 2020-01-31T20:00:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
2025-12-17 07:52:20.575 WARNING │ ┌─ Flow Boiler(Q_th) has a relative_minimum of <xarray.DataArray 'Boiler(Q_th)|relative_minimum' (time: 186)> Size: 1kB │ │ array([0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, │ │ 0.1, 0.1, 0.1, 0.1]) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 1kB 2020-01-01 ... 2020-01-31T20:00:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms
MIP linopy-problem-p5987egd has 5616 rows; 5056 cols; 16609 nonzeros; 375 integer variables (375 binary)
Coefficient ranges:
Matrix [1e-05, 1e+03]
Cost [1e+00, 1e+00]
Bound [1e+00, 1e+03]
RHS [1e+00, 1e+00]
Presolving model
2424 rows, 1680 cols, 5777 nonzeros 0s
1876 rows, 1067 cols, 5690 nonzeros 0s
1866 rows, 1057 cols, 5700 nonzeros 0s
Presolve reductions: rows 1866(-3750); columns 1057(-3999); nonzeros 5700(-10909)
Solving MIP model with:
1866 rows
1057 cols (375 binary, 0 integer, 0 implied int., 682 continuous, 0 domain fixed)
5700 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% -59124376.75961 inf inf 0 0 0 0 0.0s
R 0 0 0 0.00% 2218254.926085 2289028.646004 3.09% 0 0 0 911 0.1s
C 0 0 0 0.00% 2218254.926085 2271602.112967 2.35% 1562 192 0 1113 0.1s
L 0 0 0 0.00% 2218254.926085 2218254.926085 0.00% 1562 192 0 1113 0.2s
1 0 1 100.00% 2218254.926085 2218254.926085 0.00% 1562 192 0 1200 0.2s
Solving report
Model linopy-problem-p5987egd
Status Optimal
Primal bound 2218254.92609
Dual bound 2218254.92609
Gap 0% (tolerance: 1%)
P-D integral 0.00415481590356
Solution status feasible
2218254.92609 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.21
Max sub-MIP depth 1
Nodes 1
Repair LPs 0
LP iterations 1200
0 (strong br.)
202 (separation)
87 (heuristics)
=== Stage 1: Sizing ===
Time: 2.30 seconds
Optimized sizes:
CHP(Q_th): 300.0
Boiler(Q_th): 0.0
Storage: 1000.0
In [8]:
Copied!
# Stage 2: Dispatch at full resolution with fixed sizes
start = timeit.default_timer()
fs_dispatch = flow_system.transform.fix_sizes(fs_sizing.statistics.sizes)
fs_dispatch.optimize(solver)
time_stage2 = timeit.default_timer() - start
print('=== Stage 2: Dispatch ===')
print(f'Time: {time_stage2:.2f} seconds')
print(f'Cost: {fs_dispatch.solution["costs"].item():.2f} €')
print(f'\nTotal two-stage time: {time_stage1 + time_stage2:.2f} seconds')
# Stage 2: Dispatch at full resolution with fixed sizes start = timeit.default_timer() fs_dispatch = flow_system.transform.fix_sizes(fs_sizing.statistics.sizes) fs_dispatch.optimize(solver) time_stage2 = timeit.default_timer() - start print('=== Stage 2: Dispatch ===') print(f'Time: {time_stage2:.2f} seconds') print(f'Cost: {fs_dispatch.solution["costs"].item():.2f} €') print(f'\nTotal two-stage time: {time_stage1 + time_stage2:.2f} seconds')
2025-12-17 07:52:22.702 WARNING │ ┌─ Flow CHP(Q_th) has a relative_minimum of <xarray.DataArray 'CHP(Q_th)|relative_minimum' (time: 2976)> Size: 24kB │ │ array([0.3, 0.3, 0.3, ..., 0.3, 0.3, 0.3], shape=(2976,)) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 24kB 2020-01-01 ... 2020-01-31T23:45:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
2025-12-17 07:52:22.828 WARNING │ ┌─ Flow Boiler(Q_th) has a relative_minimum of <xarray.DataArray 'Boiler(Q_th)|relative_minimum' (time: 2976)> Size: 24kB │ │ array([0.1, 0.1, 0.1, ..., 0.1, 0.1, 0.1], shape=(2976,)) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 24kB 2020-01-01 ... 2020-01-31T23:45:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
Writing constraints.: 0%| | 0/58 [00:00<?, ?it/s]
Writing constraints.: 29%|██▉ | 17/58 [00:00<00:00, 165.49it/s]
Writing constraints.: 59%|█████▊ | 34/58 [00:00<00:00, 153.52it/s]
Writing constraints.: 86%|████████▌ | 50/58 [00:00<00:00, 145.54it/s]
Writing constraints.: 100%|██████████| 58/58 [00:00<00:00, 144.67it/s]
Writing continuous variables.: 0%| | 0/55 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 55/55 [00:00<00:00, 689.34it/s]
Writing binary variables.: 0%| | 0/2 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 2/2 [00:00<00:00, 529.42it/s] Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms MIP linopy-problem-qt5cidoi has 89310 rows; 80383 cols; 264907 nonzeros; 5952 integer variables (5952 binary) Coefficient ranges: Matrix [1e-05, 2e+02] Cost [1e+00, 1e+00] Bound [1e+00, 1e+03] RHS [1e+00, 1e+00] Presolving model 23808 rows, 23808 cols, 59519 nonzeros 0s 19585 rows, 18338 cols, 49826 nonzeros 0s
19584 rows, 18337 cols, 49824 nonzeros 0s
Presolve reductions: rows 19584(-69726); columns 18337(-62046); nonzeros 49824(-215083)
Solving MIP model with:
19584 rows
18337 cols (5952 binary, 0 integer, 0 implied int., 12385 continuous, 0 domain fixed)
49824 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% -15428368.78126 inf inf 0 0 0 0 0.4s
R 0 0 0 0.00% 2209206.133553 2278534.005751 3.04% 0 0 0 14718 0.7s
C 0 0 0 0.00% 2209206.133553 2274784.532158 2.88% 7974 2874 0 17665 1.6s
L 0 0 0 0.00% 2209206.133553 2209206.174646 0.00% 7975 2894 0 17684 3.3s
1 0 1 100.00% 2209206.133553 2209206.174646 0.00% 7975 2894 0 21090 3.3s
Solving report
Model linopy-problem-qt5cidoi
Status Optimal
Primal bound 2209206.17465
Dual bound 2209206.13355
Gap 2e-06% (tolerance: 1%)
P-D integral 0.0762374890187
Solution status feasible
2209206.17465 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 3.28
Max sub-MIP depth 2
Nodes 1
Repair LPs 0
LP iterations 21090
0 (strong br.)
2966 (separation)
3406 (heuristics)
=== Stage 2: Dispatch === Time: 5.84 seconds Cost: 2209206.17 € Total two-stage time: 8.15 seconds
Technique 3: Full Optimization (Baseline)¶
For comparison, solve the full problem:
In [9]:
Copied!
start = timeit.default_timer()
fs_full = flow_system.copy()
fs_full.optimize(solver)
time_full = timeit.default_timer() - start
print('=== Full Optimization ===')
print(f'Time: {time_full:.2f} seconds')
print(f'Cost: {fs_full.solution["costs"].item():.2f} €')
start = timeit.default_timer() fs_full = flow_system.copy() fs_full.optimize(solver) time_full = timeit.default_timer() - start print('=== Full Optimization ===') print(f'Time: {time_full:.2f} seconds') print(f'Cost: {fs_full.solution["costs"].item():.2f} €')
2025-12-17 07:52:28.549 WARNING │ ┌─ Flow CHP(Q_th) has a relative_minimum of <xarray.DataArray 'CHP(Q_th)|relative_minimum' (time: 2976)> Size: 24kB │ │ array([0.3, 0.3, 0.3, ..., 0.3, 0.3, 0.3], shape=(2976,)) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 24kB 2020-01-01 ... 2020-01-31T23:45:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
2025-12-17 07:52:28.692 WARNING │ ┌─ Flow Boiler(Q_th) has a relative_minimum of <xarray.DataArray 'Boiler(Q_th)|relative_minimum' (time: 2976)> Size: 24kB │ │ array([0.1, 0.1, 0.1, ..., 0.1, 0.1, 0.1], shape=(2976,)) │ │ Coordinates: │ └─ * time (time) datetime64[ns] 24kB 2020-01-01 ... 2020-01-31T23:45:00 and no status_parameters. This prevents the Flow from switching inactive (flow_rate = 0). Consider using status_parameters to allow the Flow to be switched active and inactive.
Writing constraints.: 0%| | 0/64 [00:00<?, ?it/s]
Writing constraints.: 28%|██▊ | 18/64 [00:00<00:00, 174.60it/s]
Writing constraints.: 56%|█████▋ | 36/64 [00:00<00:00, 162.57it/s]
Writing constraints.: 83%|████████▎ | 53/64 [00:00<00:00, 154.01it/s]
Writing constraints.: 100%|██████████| 64/64 [00:00<00:00, 151.26it/s]
Writing continuous variables.: 0%| | 0/55 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 55/55 [00:00<00:00, 708.18it/s]
Writing binary variables.: 0%| | 0/5 [00:00<?, ?it/s]
Writing binary variables.: 100%|██████████| 5/5 [00:00<00:00, 843.72it/s] Running HiGHS 1.12.0 (git hash: 755a8e0): Copyright (c) 2025 HiGHS under MIT licence terms MIP linopy-problem-9gke46uk has 89316 rows; 80386 cols; 264919 nonzeros; 5955 integer variables (5955 binary) Coefficient ranges: Matrix [1e-05, 1e+03] Cost [1e+00, 1e+00] Bound [1e+00, 1e+03] RHS [1e+00, 1e+00] Presolving model 38694 rows, 26790 cols, 92267 nonzeros 0s
31169 rows, 18018 cols, 88849 nonzeros 0s 30836 rows, 17685 cols, 89182 nonzeros 0s Presolve reductions: rows 30836(-58480); columns 17685(-62701); nonzeros 89182(-175737) Solving MIP model with: 30836 rows 17685 cols (5955 binary, 0 integer, 0 implied int., 11730 continuous, 0 domain fixed) 89182 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
0 0 0 0.00% -48251946.82856 inf inf 0 0 0 0 0.6s
R 0 0 0 0.00% 2209206.133553 2278967.860722 3.06% 0 0 0 15446 1.6s
C 0 0 0 0.00% 2209206.133553 2276813.637485 2.97% 7397 2705 0 18258 4.2s
L 0 0 0 0.00% 2209206.133553 2209206.175206 0.00% 7861 3041 0 18604 11.3s
1 0 1 100.00% 2209206.133553 2209206.175206 0.00% 7861 3041 0 21662 11.3s
Solving report
Model linopy-problem-9gke46uk
Status Optimal
Primal bound 2209206.17521
Dual bound 2209206.13355
Gap 2e-06% (tolerance: 1%)
P-D integral 0.290328670964
Solution status feasible
2209206.17521 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 11.29
Max sub-MIP depth 2
Nodes 1
Repair LPs 0
LP iterations 21662
0 (strong br.)
3158 (separation)
3058 (heuristics)
=== Full Optimization === Time: 13.91 seconds Cost: 2209206.18 €
Compare Results¶
In [10]:
Copied!
# Collect results
results = {
'Full (baseline)': {
'Time [s]': time_full,
'Cost [€]': fs_full.solution['costs'].item(),
'CHP Size [MW]': fs_full.statistics.sizes['CHP(Q_th)'].item(),
'Boiler Size [MW]': fs_full.statistics.sizes['Boiler(Q_th)'].item(),
'Storage Size [MWh]': fs_full.statistics.sizes['Storage'].item(),
},
'Resampled (4h)': {
'Time [s]': time_resampled,
'Cost [€]': fs_resampled.solution['costs'].item(),
'CHP Size [MW]': fs_resampled.statistics.sizes['CHP(Q_th)'].item(),
'Boiler Size [MW]': fs_resampled.statistics.sizes['Boiler(Q_th)'].item(),
'Storage Size [MWh]': fs_resampled.statistics.sizes['Storage'].item(),
},
'Two-Stage': {
'Time [s]': time_stage1 + time_stage2,
'Cost [€]': fs_dispatch.solution['costs'].item(),
'CHP Size [MW]': fs_dispatch.statistics.sizes['CHP(Q_th)'].item(),
'Boiler Size [MW]': fs_dispatch.statistics.sizes['Boiler(Q_th)'].item(),
'Storage Size [MWh]': fs_dispatch.statistics.sizes['Storage'].item(),
},
}
comparison = pd.DataFrame(results).T
# Add relative metrics
baseline_cost = comparison.loc['Full (baseline)', 'Cost [€]']
baseline_time = comparison.loc['Full (baseline)', 'Time [s]']
comparison['Cost Gap [%]'] = ((comparison['Cost [€]'] - baseline_cost) / baseline_cost * 100).round(2)
comparison['Speedup'] = (baseline_time / comparison['Time [s]']).round(1)
comparison.style.format(
{
'Time [s]': '{:.2f}',
'Cost [€]': '{:,.0f}',
'CHP Size [MW]': '{:.1f}',
'Boiler Size [MW]': '{:.1f}',
'Storage Size [MWh]': '{:.0f}',
'Cost Gap [%]': '{:.2f}',
'Speedup': '{:.1f}x',
}
)
# Collect results results = { 'Full (baseline)': { 'Time [s]': time_full, 'Cost [€]': fs_full.solution['costs'].item(), 'CHP Size [MW]': fs_full.statistics.sizes['CHP(Q_th)'].item(), 'Boiler Size [MW]': fs_full.statistics.sizes['Boiler(Q_th)'].item(), 'Storage Size [MWh]': fs_full.statistics.sizes['Storage'].item(), }, 'Resampled (4h)': { 'Time [s]': time_resampled, 'Cost [€]': fs_resampled.solution['costs'].item(), 'CHP Size [MW]': fs_resampled.statistics.sizes['CHP(Q_th)'].item(), 'Boiler Size [MW]': fs_resampled.statistics.sizes['Boiler(Q_th)'].item(), 'Storage Size [MWh]': fs_resampled.statistics.sizes['Storage'].item(), }, 'Two-Stage': { 'Time [s]': time_stage1 + time_stage2, 'Cost [€]': fs_dispatch.solution['costs'].item(), 'CHP Size [MW]': fs_dispatch.statistics.sizes['CHP(Q_th)'].item(), 'Boiler Size [MW]': fs_dispatch.statistics.sizes['Boiler(Q_th)'].item(), 'Storage Size [MWh]': fs_dispatch.statistics.sizes['Storage'].item(), }, } comparison = pd.DataFrame(results).T # Add relative metrics baseline_cost = comparison.loc['Full (baseline)', 'Cost [€]'] baseline_time = comparison.loc['Full (baseline)', 'Time [s]'] comparison['Cost Gap [%]'] = ((comparison['Cost [€]'] - baseline_cost) / baseline_cost * 100).round(2) comparison['Speedup'] = (baseline_time / comparison['Time [s]']).round(1) comparison.style.format( { 'Time [s]': '{:.2f}', 'Cost [€]': '{:,.0f}', 'CHP Size [MW]': '{:.1f}', 'Boiler Size [MW]': '{:.1f}', 'Storage Size [MWh]': '{:.0f}', 'Cost Gap [%]': '{:.2f}', 'Speedup': '{:.1f}x', } )
Out[10]:
| Time [s] | Cost [€] | CHP Size [MW] | Boiler Size [MW] | Storage Size [MWh] | Cost Gap [%] | Speedup | |
|---|---|---|---|---|---|---|---|
| Full (baseline) | 13.91 | 2,209,206 | 300.0 | 0.0 | 1000 | 0.00 | 1.0x |
| Resampled (4h) | 2.08 | 2,218,255 | 300.0 | 0.0 | 1000 | 0.41 | 6.7x |
| Two-Stage | 8.15 | 2,209,206 | 300.0 | 0.0 | 1000 | -0.00 | 1.7x |
Visual Comparison: Heat Balance¶
In [11]:
Copied!
# Full optimization heat balance
fs_full.statistics.plot.balance('Heat')
# Full optimization heat balance fs_full.statistics.plot.balance('Heat')
Out[11]:
In [12]:
Copied!
# Two-stage optimization heat balance
fs_dispatch.statistics.plot.balance('Heat')
# Two-stage optimization heat balance fs_dispatch.statistics.plot.balance('Heat')
Out[12]:
Energy Flow Sankey (Full Optimization)¶
A Sankey diagram visualizes the total energy flows:
In [13]:
Copied!
fs_full.statistics.plot.sankey.flows()
fs_full.statistics.plot.sankey.flows()
Out[13]:
When to Use Each Technique¶
| Technique | Best For | Trade-off |
|---|---|---|
| Full optimization | Final results, small problems | Slowest, most accurate |
| Resampling | Quick screening, trend analysis | Fast, loses temporal detail |
| Two-stage | Investment decisions, large problems | Good balance of speed and accuracy |
| Clustering | Preserves extreme periods | Requires tsam package |
Resampling Options¶
# Different resolutions
fs_2h = flow_system.transform.resample('2h') # 2-hourly
fs_4h = flow_system.transform.resample('4h') # 4-hourly
fs_daily = flow_system.transform.resample('1D') # Daily
# Different aggregation methods
fs_mean = flow_system.transform.resample('4h', method='mean') # Default
fs_max = flow_system.transform.resample('4h', method='max') # Preserve peaks
Two-Stage Workflow¶
# Stage 1: Sizing
fs_sizing = flow_system.transform.resample('4h')
fs_sizing.optimize(solver)
# Stage 2: Dispatch
fs_dispatch = flow_system.transform.fix_sizes(fs_sizing.statistics.sizes)
fs_dispatch.optimize(solver)
Summary¶
You learned how to:
- Use
transform.resample()to reduce time resolution - Apply two-stage optimization for large investment problems
- Use
transform.fix_sizes()to lock in investment decisions - Compare speed vs. accuracy trade-offs
Key Takeaways¶
- Start fast: Use resampling for initial exploration
- Iterate: Refine with two-stage optimization
- Validate: Run full optimization for final results
- Monitor: Check cost gaps to ensure acceptable accuracy
Next Steps¶
- 08b-Rolling Horizon: For operational problems without investment decisions, decompose time into sequential segments
Further Reading¶
- For clustering with typical periods, see
transform.cluster()(requirestsampackage) - For time selection, see
transform.sel()andtransform.isel()